PART 03의 마무리는 랜드마크를 정렬이 아닌 다른 곳에 써 보는 실습입니다. 68점만 있으면 눈이 얼마나 감겼는지로 깜빡임과 졸음을 감지하고, 코·눈·입의 위치로 머리가 어느 방향을 보는지 추정할 수 있습니다. 운전자 졸음 경고나 화상 강의 집중도 모니터링의 핵심 원리가 바로 이것입니다.
눈 가로세로비(EAR)로 깜빡임 감지
눈을 감으면 위아래 눈꺼풀이 가까워져 눈의 세로 길이가 짧아집니다. 이 변화를 숫자로 잡은 것이 눈 가로세로비(EAR, Eye Aspect Ratio)입니다. 한쪽 눈의 6개 점을 p1~p6이라 할 때, 세로 거리의 합을 가로 거리로 나눕니다.
EAR이 평소에는 0.3 안팎이다가 눈을 감으면 0.1 아래로 뚝 떨어집니다. 그래서 임계값(보통 0.2 정도)을 정해 그 아래로 내려가면 "눈을 감았다"고 판단하고, 일정 시간 이상 계속 감겨 있으면 졸음으로 봅니다.
# 파일: drowsiness.py"""68점 랜드마크의 EAR로 깜빡임·졸음을 감지한다."""import cv2import dlibimport numpy as npdetector = dlib.get_frontal_face_detector()predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")LEFT_EYE = list(range(36, 42)) # 왼쪽 눈 6점RIGHT_EYE = list(range(42, 48)) # 오른쪽 눈 6점EAR_THRESHOLD = 0.2 # 이 값 미만이면 눈 감음CLOSED_FRAMES = 15 # 연속 프레임이 이만큼이면 졸음def eye_aspect_ratio(eye): # eye: 6점 좌표. 세로 두 쌍 / 가로 한 쌍 a = np.linalg.norm(eye[1] - eye[5]) b = np.linalg.norm(eye[2] - eye[4]) c = np.linalg.norm(eye[0] - eye[3]) return (a + b) / (2.0 * c)cap = cv2.VideoCapture(0)counter = 0while True: ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) for rect in detector(gray, 0): shape = predictor(gray, rect) pts = np.array([[shape.part(i).x, shape.part(i).y] for i in range(68)]) ear = (eye_aspect_ratio(pts[LEFT_EYE]) + eye_aspect_ratio(pts[RIGHT_EYE])) / 2.0 if ear < EAR_THRESHOLD: counter += 1 if counter >= CLOSED_FRAMES: cv2.putText(frame, "DROWSY!", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2, cv2.LINE_AA) else: counter = 0 cv2.putText(frame, f"EAR: {ear:.2f}", (10, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2, cv2.LINE_AA) cv2.imshow("drowsiness - press q to quit", frame) if cv2.waitKey(1) & 0xFF == ord("q"): breakcap.release()cv2.destroyAllWindows()
eye_aspect_ratio 함수가 EAR 공식 그대로입니다. 위아래 두 쌍의 세로 거리를 더해 가로 거리의 두 배로 나눕니다. 눈을 감으면 이 값이 급락하고, CLOSED_FRAMES(여기서는 15프레임) 이상 연속으로 임계값 아래면 "DROWSY!"를 띄웁니다. 한 번 깜빡이는 것과 졸음을 구분하려고 연속 프레임 수를 세는 것이 핵심입니다.
solvePnP로 머리 자세 추정
머리가 어느 방향을 보는지는 2D 랜드마크와 일반적인 3D 얼굴 모델을 맞춰서 알아냅니다. OpenCV의 solvePnP가 "이 2D 점들이 3D 얼굴의 어떤 자세에서 나왔는가"를 풀어 주고, 그 결과로 고개를 끄덕임·돌림·기울임의 각도를 얻습니다.
# 파일: head_pose.py (shape를 구한 뒤의 핵심부)import cv2import numpy as np# 일반적인 얼굴의 3D 기준점 (단위: 임의, 코끝이 원점)MODEL_3D = np.array([ (0.0, 0.0, 0.0), # 코끝 (30번) (0.0, -330.0, -65.0), # 턱 (8번) (-225.0, 170.0, -135.0), # 왼쪽 눈 바깥 (36번) (225.0, 170.0, -135.0), # 오른쪽 눈 바깥 (45번) (-150.0, -150.0, -125.0), # 왼쪽 입꼬리 (48번) (150.0, -150.0, -125.0), # 오른쪽 입꼬리 (54번)], dtype=np.float64)IDX = [30, 8, 36, 45, 48, 54] # 위 3D 점에 대응하는 68점 번호def head_pose(pts, size): image_2d = np.array([pts[i] for i in IDX], dtype=np.float64) h, w = size focal = w # 초점거리 근사 K = np.array([[focal, 0, w / 2], [0, focal, h / 2], [0, 0, 1]], dtype=np.float64) dist = np.zeros((4, 1)) # 왜곡 없음 가정 ok, rvec, tvec = cv2.solvePnP(MODEL_3D, image_2d, K, dist) rmat, _ = cv2.Rodrigues(rvec) # 회전벡터 → 회전행렬 # 회전행렬에서 오일러각(피치·요·롤) 추출 sy = np.sqrt(rmat[0, 0] ** 2 + rmat[1, 0] ** 2) pitch = np.degrees(np.arctan2(-rmat[2, 0], sy)) yaw = np.degrees(np.arctan2(rmat[1, 0], rmat[0, 0])) roll = np.degrees(np.arctan2(rmat[2, 1], rmat[2, 2])) return pitch, yaw, roll
MODEL_3D는 일반적인 얼굴의 3D 좌표이고, IDX는 거기에 대응하는 68점 번호입니다. solvePnP가 둘을 맞춰 회전 벡터를 주면, Rodrigues로 회전 행렬로 바꾼 뒤 고개의 위아래(pitch)·좌우(yaw)·갸웃(roll) 각도를 뽑습니다. yaw가 크면 옆을 보는 것, pitch가 아래로 크면 고개를 숙인 것입니다.
두 응용을 합치면
EAR로 졸음을, 머리 자세로 주의 방향을 동시에 보면 "운전자가 눈을 감았고 고개가 숙여졌다" 같은 종합 판단이 가능합니다. 이것이 운전자 모니터링 시스템(DMS)이나 화상 강의 집중도 분석의 뼈대입니다. PART 10의 종합 프로젝트에서 이 조각들을 실제 시스템으로 엮습니다.
실무 팁. EAR 임계값(0.2)과 졸음 판정 프레임 수(15)는 절대적인 값이 아닙니다. 카메라 프레임 속도, 사람마다 다른 눈 크기, 안경 착용 여부에 따라 조정해야 합니다. 실제 운영 전에 대상 사용자의 평소 EAR을 몇 초간 측정해 기준선을 잡고, 그 기준선의 일정 비율(예: 70%)을 임계값으로 삼으면 개인차에 강해집니다.
이 장에서 기억할 것
68점 랜드마크는 정렬 외에도 풍부하게 쓰입니다. 눈 6점으로 EAR을 계산해 깜빡임과 졸음을 감지하고, 코·눈·입 6점과 일반 3D 얼굴 모델을 solvePnP로 맞춰 머리 자세(pitch·yaw·roll)를 추정합니다. 임계값과 프레임 수는 환경에 맞춰 조정하는 값입니다. 이로써 PART 03이 끝납니다. 다음 PART 04에서는 드디어 "이 얼굴이 누구인가"를 푸는 얼굴 임베딩과 신원 인식의 직관으로 들어갑니다.